{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Project 1 - Unit Testing"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "As you can imagine to fully test this code is going to take quite a bit of effort. I'm not going to do this here, so I very well may have bugs in my code. "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In practice, a whole suite of tests should be written to ensure this code is working properly."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "If you're thinking this will be as much work as writing the code itself - well... It will probably be **more** work.\n",
    "\n",
    "In my experience, designing and implementing unit tests takes longer than writing the code in the first place!"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Here's a manual approach to a bit of integration testing:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "First, our code:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "import itertools\n",
    "import numbers\n",
    "from datetime import timedelta, datetime\n",
    "from collections import namedtuple\n",
    "\n",
    "\n",
    "class TimeZone:\n",
    "    def __init__(self, name, offset_hours, offset_minutes):\n",
    "        if name is None or len(str(name).strip()) == 0:\n",
    "            raise ValueError('Timezone name cannot be empty.')\n",
    "            \n",
    "        self._name = str(name).strip()\n",
    "        # technically we should check that offset is a\n",
    "        if not isinstance(offset_hours, numbers.Integral):\n",
    "            raise ValueError('Hour offset must be an integer.')\n",
    "        \n",
    "        if not isinstance(offset_minutes, numbers.Integral):\n",
    "            raise ValueError('Minutes offset must be an integer.')\n",
    "            \n",
    "        if offset_minutes < -59 or offset_minutes > 59:\n",
    "            raise ValueError('Minutes offset must between -59 and 59 (inclusive).')\n",
    "            \n",
    "        # for time delta sign of minutes will be set to sign of hours\n",
    "        offset = timedelta(hours=offset_hours, minutes=offset_minutes)\n",
    "\n",
    "        # offsets are technically bounded between -12:00 and 14:00\n",
    "        # see: https://en.wikipedia.org/wiki/List_of_UTC_time_offsets\n",
    "        if offset < timedelta(hours=-12, minutes=0) or offset > timedelta(hours=14, minutes=0):\n",
    "            raise ValueError('Offset must be between -12:00 and +14:00.')\n",
    "            \n",
    "        self._offset_hours = offset_hours\n",
    "        self._offset_minutes = offset_minutes\n",
    "        self._offset = offset\n",
    "        \n",
    "    @property\n",
    "    def offset(self):\n",
    "        return self._offset\n",
    "    \n",
    "    @property\n",
    "    def name(self):\n",
    "        return self._name\n",
    "    \n",
    "    def __eq__(self, other):\n",
    "        return (isinstance(other, TimeZone) and \n",
    "                self.name == other.name and \n",
    "                self._offset_hours == other._offset_hours and\n",
    "                self._offset_minutes == other._offset_minutes)\n",
    "    def __repr__(self):\n",
    "        return (f\"TimeZone(name='{self.name}', \"\n",
    "                f\"offset_hours={self._offset_hours}, \"\n",
    "                f\"offset_minutes={self._offset_minutes})\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Account:\n",
    "    transaction_counter = itertools.count(100)\n",
    "    _interest_rate = 0.5  # percentage\n",
    "    \n",
    "    _transaction_codes = {\n",
    "        'deposit': 'D',\n",
    "        'withdraw': 'W',\n",
    "        'interest': 'I',\n",
    "        'rejected': 'X'\n",
    "    }\n",
    "    \n",
    "    def __init__(self, account_number, first_name, last_name, timezone=None, initial_balance=0):\n",
    "        # in practice we probably would want to add checks to make sure these values are valid / non-empty\n",
    "        self._account_number = account_number\n",
    "        self.first_name = first_name\n",
    "        self.last_name = last_name\n",
    "        \n",
    "        if timezone is None:\n",
    "            timezone = TimeZone('UTC', 0, 0)\n",
    "        self.timezone = timezone\n",
    "        \n",
    "        self._balance = Account.validate_real_number(initial_balance)\n",
    "        \n",
    "    @property\n",
    "    def account_number(self):\n",
    "        return self._account_number\n",
    "    \n",
    "    @property \n",
    "    def first_name(self):\n",
    "        return self._first_name\n",
    "    \n",
    "    @first_name.setter\n",
    "    def first_name(self, value):\n",
    "        self.validate_and_set_name('_first_name', value, 'First Name')\n",
    "        \n",
    "    @property\n",
    "    def last_name(self):\n",
    "        return self._last_name\n",
    "    \n",
    "    @last_name.setter\n",
    "    def last_name(self, value):\n",
    "        self.validate_and_set_name('_last_name', value, 'Last Name')\n",
    "        \n",
    "    # also going to create a full_name computed property, for ease of use\n",
    "    @property\n",
    "    def full_name(self):\n",
    "        return f'{self.first_name} {self.last_name}'\n",
    "        \n",
    "    @property\n",
    "    def timezone(self):\n",
    "        return self._timezone\n",
    "    \n",
    "    @property\n",
    "    def balance(self):\n",
    "        return self._balance\n",
    "    \n",
    "    @timezone.setter\n",
    "    def timezone(self, value):\n",
    "        if not isinstance(value, TimeZone):\n",
    "            raise ValueError('Time zone must be a valid TimeZone object.')\n",
    "        self._timezone = value\n",
    "          \n",
    "    @classmethod\n",
    "    def get_interest_rate(cls):\n",
    "        return cls._interest_rate\n",
    "    \n",
    "    @classmethod\n",
    "    def set_interest_rate(cls, value):\n",
    "        if not isinstance(value, numbers.Real):\n",
    "            raise ValueError('Interest rate must be a real number')\n",
    "        if value < 0:\n",
    "            raise ValueError('Interest rate cannot be negative.')\n",
    "        cls._interest_rate = value\n",
    "        \n",
    "    def validate_and_set_name(self, property_name, value, field_title):\n",
    "        if value is None or len(str(value).strip()) == 0:\n",
    "            raise ValueError(f'{field_title} cannot be empty.')\n",
    "        setattr(self, property_name, value)\n",
    "        \n",
    "    @staticmethod\n",
    "    def validate_real_number(value, min_value=None):\n",
    "        if not isinstance(value, numbers.Real):\n",
    "            raise ValueError('Value must be a real number.')\n",
    "            \n",
    "        if min_value is not None and value < min_value:\n",
    "            raise ValueError(f'Value must be at least {min_value}')\n",
    "            \n",
    "        # validation passed, return valid value\n",
    "        return value\n",
    "    \n",
    "    def generate_confirmation_code(self, transaction_code):\n",
    "        # main difficulty here is to generate the current time in UTC using this formatting:\n",
    "        # YYYYMMDDHHMMSS\n",
    "        dt_str = datetime.utcnow().strftime('%Y%m%d%H%M%S')\n",
    "        return f'{transaction_code}-{self.account_number}-{dt_str}-{next(Account.transaction_counter)}'\n",
    "    \n",
    "    @staticmethod\n",
    "    def parse_confirmation_code(confirmation_code, preferred_time_zone=None):\n",
    "        # dummy-A100-20190325224918-101\n",
    "        parts = confirmation_code.split('-')\n",
    "        if len(parts) != 4:\n",
    "            # really simplistic validation here - would need something better\n",
    "            raise ValueError('Invalid confirmation code')\n",
    "        \n",
    "        # unpack into separate variables\n",
    "        transaction_code, account_number, raw_dt_utc, transaction_id = parts\n",
    "        \n",
    "        # need to convert raw_dt_utc into a proper datetime object\n",
    "        try:\n",
    "            dt_utc = datetime.strptime(raw_dt_utc, '%Y%m%d%H%M%S')\n",
    "        except ValueError as ex:\n",
    "            # again, probably need better error handling here\n",
    "            raise ValueError('Invalid transaction datetime') from ex\n",
    "          \n",
    "        if preferred_time_zone is None:\n",
    "            preferred_time_zone = TimeZone('UTC', 0, 0)\n",
    "            \n",
    "        if not isinstance(preferred_time_zone, TimeZone):\n",
    "            raise ValueError('Invalid TimeZone specified.')\n",
    "            \n",
    "        dt_preferred = dt_utc + preferred_time_zone.offset\n",
    "        dt_preferred_str = f\"{dt_preferred.strftime('%Y-%m-%d %H:%M:%S')} ({preferred_time_zone.name})\"\n",
    "        \n",
    "        return Confirmation(account_number, transaction_code, transaction_id, dt_utc.isoformat(), dt_preferred_str)\n",
    "    \n",
    "    def deposit(self, value):\n",
    "        value = Account.validate_real_number(value, min_value=0.01)\n",
    "       \n",
    "        # get transaction code\n",
    "        transaction_code = Account._transaction_codes['deposit']\n",
    "        \n",
    "        # generate a confirmation code\n",
    "        conf_code = self.generate_confirmation_code(transaction_code)\n",
    "        \n",
    "        # make deposit and return conf code\n",
    "        self._balance += value\n",
    "        return conf_code\n",
    "    \n",
    "    def withdraw(self, value):\n",
    "        value = Account.validate_real_number(value, min_value=0.01)\n",
    "        accepted = False\n",
    "        if self.balance - value < 0:\n",
    "            # insufficient funds - we'll reject this transaction\n",
    "            transaction_code = Account._transaction_codes['rejected']\n",
    "        else:\n",
    "            transaction_code = Account._transaction_codes['withdraw']\n",
    "            accepted = True\n",
    "            \n",
    "        conf_code = self.generate_confirmation_code(transaction_code)\n",
    "        \n",
    "        # Doing this here in case there's a problem generating a confirmation code\n",
    "        # - do not want to modify the balance if we cannot generate a transaction code successfully\n",
    "        if accepted:\n",
    "            self._balance -= value\n",
    "            \n",
    "        return conf_code\n",
    "    \n",
    "    def pay_interest(self):\n",
    "        interest = self.balance * Account.get_interest_rate() / 100\n",
    "        conf_code = self.generate_confirmation_code(Account._transaction_codes['interest'])\n",
    "        self._balance += interest\n",
    "        return conf_code"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "100\n",
      "D-A100-20190602232958-100\n",
      "250.02\n",
      "W-A100-20190602232958-101\n",
      "250.0\n",
      "1.0\n",
      "I-A100-20190602232958-102\n",
      "252.5\n",
      "X-A100-20190602232958-103\n"
     ]
    }
   ],
   "source": [
    "a = Account('A100', 'Eric', 'Idle', timezone=TimeZone('MST', -7, 0), initial_balance=100)\n",
    "print(a.balance)\n",
    "print(a.deposit(150.02))\n",
    "print(a.balance)\n",
    "print(a.withdraw(0.02))\n",
    "print(a.balance)\n",
    "Account.set_interest_rate(1.0)\n",
    "print(a.get_interest_rate())\n",
    "print(a.pay_interest())\n",
    "print(a.balance)\n",
    "print(a.withdraw(1000))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "OK, so that works, but of course we really need to test things a whole lot more, including various scenarios (like withdrawing with insufficient funds, and so on). Also our tests really need to be easily repeatable so we can re-run our tests every time we make a code change.\n",
    "\n",
    "For that I'm going to introduce you to the `unitest` framework in Python.\n",
    "\n",
    "In production environments, a 3rd party library (that leverages `unittest`) is used that extends the base `unittest` framework. But for now, `unittest` will work just fine for us."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Normally, unit tests are invoked from the command line, which in turn sets up a test runner and seamlessly runs our tests. In this case, so I can stay within a Jupyter Notebook I'll have to add a few extra chunks of code to set up a test runner manually - this is usually not necessary."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [],
   "source": [
    "import unittest"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [],
   "source": [
    "def run_tests(test_class):\n",
    "    suite = unittest.TestLoader().loadTestsFromTestCase(test_class)\n",
    "    runner = unittest.TextTestRunner(verbosity=2)\n",
    "    result = runner.run(suite)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's see how some simple tests are setup and executed:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    def test_ok(self):\n",
    "        self.assertEqual(1, 1)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_ok (__main__.TestAccount) ... ok\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 1 test in 0.001s\n",
      "\n",
      "OK\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In cases where the a test fails:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    def test_ok(self):\n",
    "        self.assertEqual(1, 0)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_ok (__main__.TestAccount) ... FAIL\n",
      "\n",
      "======================================================================\n",
      "FAIL: test_ok (__main__.TestAccount)\n",
      "----------------------------------------------------------------------\n",
      "Traceback (most recent call last):\n",
      "  File \"<ipython-input-8-b9c1ce72eb43>\", line 3, in test_ok\n",
      "    self.assertEqual(1, 0)\n",
      "AssertionError: 1 != 0\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 1 test in 0.001s\n",
      "\n",
      "FAILED (failures=1)\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "So now we can write some simple unit tests. The only thing is that each unit test should be a function in the class that starts with the word `test` - that way it is automatically identified as a unit test.\n",
    "\n",
    "We also have the option of defining setup and tear down functionality - these are just methods that will be executed before **each** test method, and right **after**. - here's a simple example that shows how that works:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    def setUp(self):\n",
    "        print('Running setup...')\n",
    "        self.account_number = 'A100'\n",
    "        \n",
    "    def tearDown(self):\n",
    "        print('Running tear down...')\n",
    "        \n",
    "    def test_1(self):\n",
    "        self.account_number = 'A200'\n",
    "        self.assertTrue('A200', self.account_number)\n",
    "        \n",
    "    def test_2(self):\n",
    "        self.assertTrue('A100', self.account_number)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_1 (__main__.TestAccount) ... ok\n",
      "test_2 (__main__.TestAccount) ... "
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Running setup...\n",
      "Running tear down...\n",
      "Running setup...\n",
      "Running tear down...\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "ok\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 2 tests in 0.001s\n",
      "\n",
      "OK\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Even, if the test fails, the tear down method will still run:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    def setUp(self):\n",
    "        print('Running setup...')\n",
    "        \n",
    "    def tearDown(self):\n",
    "        print('Running tear down...')\n",
    "        \n",
    "    def testOK(self):\n",
    "        self.assertTrue(False)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "testOK (__main__.TestAccount) ... "
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Running setup...\n",
      "Running tear down...\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "FAIL\n",
      "\n",
      "======================================================================\n",
      "FAIL: testOK (__main__.TestAccount)\n",
      "----------------------------------------------------------------------\n",
      "Traceback (most recent call last):\n",
      "  File \"<ipython-input-12-12820de134fd>\", line 9, in testOK\n",
      "    self.assertTrue(False)\n",
      "AssertionError: False is not true\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 1 test in 0.001s\n",
      "\n",
      "FAILED (failures=1)\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "So we could use `setUp` to maybe create some bank accounts that we can use throughout our tests. Remember that `TestAccount` is a class, so we can create instance attributes in the `setUp` method, and access them in any of the instance methods (like the test methods).\n",
    "\n",
    "Another thing to watch out for is that there is no guarantee of the order in which the unit tests are run. Best practice is that unit tests should be independent of each other."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's add some simple unit tests for the TimeZone class first:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "   \n",
    "    def test_create_timezone(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual('ABC', tz.name)\n",
    "        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)\n",
    "        \n",
    "    def test_timezones_equal(self):\n",
    "        tz1 = TimeZone('ABC', -1, -30)\n",
    "        tz2 = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual(tz1, tz2)\n",
    "        \n",
    "    def test_timezones_not_equal(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        \n",
    "        test_timezones = (\n",
    "            TimeZone('DEF', -1, -30),\n",
    "            TimeZone('ABC', -1, 0),\n",
    "            TimeZone('ABC', 1, -30)\n",
    "        )\n",
    "        for test_tz in test_timezones:\n",
    "            self.assertNotEqual(tz, test_tz)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_create_timezone (__main__.TestAccount) ... ok\n",
      "test_timezones_equal (__main__.TestAccount) ... ok\n",
      "test_timezones_not_equal (__main__.TestAccount) ... ok\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 3 tests in 0.002s\n",
      "\n",
      "OK\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Notice how we needed to run multiple scenarios for testing non-equal time zones. This is a fairly common occurrence, and there's a better way to set this up so we actually have separate tests, that are distinguishable from each other (it's slightly easier when using pytest, but the end result is similar):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    \n",
    "    def test_create_timezone(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual('ABC', tz.name)\n",
    "        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)\n",
    "        \n",
    "    def test_timezones_equal(self):\n",
    "        tz1 = TimeZone('ABC', -1, -30)\n",
    "        tz2 = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual(tz1, tz2)\n",
    "        \n",
    "    def test_timezones_not_equal(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        \n",
    "        test_timezones = (\n",
    "            TimeZone('DEF', -1, -30),\n",
    "            TimeZone('ABC', -1, 0),\n",
    "            TimeZone('ABC', 1, -30)\n",
    "        )\n",
    "        for i, test_tz in enumerate(test_timezones):\n",
    "            with self.subTest(test_number=i):\n",
    "                self.assertNotEqual(tz, test_tz)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_create_timezone (__main__.TestAccount) ... ok\n",
      "test_timezones_equal (__main__.TestAccount) ... ok\n",
      "test_timezones_not_equal (__main__.TestAccount) ... ok\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 3 tests in 0.002s\n",
      "\n",
      "OK\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Where this might be handy is in the case of a test failure:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    \n",
    "    def test_create_timezone(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual('ABC', tz.name)\n",
    "        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)\n",
    "        \n",
    "    def test_timezones_equal(self):\n",
    "        tz1 = TimeZone('ABC', -1, -30)\n",
    "        tz2 = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual(tz1, tz2)\n",
    "        \n",
    "    def test_timezones_not_equal(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        \n",
    "        test_timezones = (\n",
    "            TimeZone('DEF', -1, -30),\n",
    "            TimeZone('ABC', -1, 0),\n",
    "            TimeZone('ABC', 1, -30),\n",
    "            TimeZone('ABC', -1, -30)\n",
    "        )\n",
    "        for i, test_tz in enumerate(test_timezones):\n",
    "            with self.subTest(test_number=i):\n",
    "                self.assertNotEqual(tz, test_tz)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 19,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_create_timezone (__main__.TestAccount) ... ok\n",
      "test_timezones_equal (__main__.TestAccount) ... ok\n",
      "test_timezones_not_equal (__main__.TestAccount) ... \n",
      "======================================================================\n",
      "FAIL: test_timezones_not_equal (__main__.TestAccount) (test_number=3)\n",
      "----------------------------------------------------------------------\n",
      "Traceback (most recent call last):\n",
      "  File \"<ipython-input-18-72e5ac56299a>\", line 24, in test_timezones_not_equal\n",
      "    self.assertNotEqual(tz, test_tz)\n",
      "AssertionError: TimeZone(name='ABC', offset_hours=-1, offset_minutes=-30) == TimeZone(name='ABC', offset_hours=-1, offset_minutes=-30)\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 3 tests in 0.002s\n",
      "\n",
      "FAILED (failures=1)\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "As you can see we have a message associated with the failed test.\n",
    "\n",
    "Let's go back and remove that incorrect test:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 20,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    \n",
    "    def test_create_timezone(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual('ABC', tz.name)\n",
    "        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)\n",
    "        \n",
    "    def test_timezones_equal(self):\n",
    "        tz1 = TimeZone('ABC', -1, -30)\n",
    "        tz2 = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual(tz1, tz2)\n",
    "        \n",
    "    def test_timezones_not_equal(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        \n",
    "        test_timezones = (\n",
    "            TimeZone('DEF', -1, -30),\n",
    "            TimeZone('ABC', -1, 0),\n",
    "            TimeZone('ABC', 1, -30)\n",
    "        )\n",
    "        for i, test_tz in enumerate(test_timezones):\n",
    "            with self.subTest(test_number=i):\n",
    "                self.assertNotEqual(tz, test_tz)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now we can start adding additional unit tests for our Account class.\n",
    "\n",
    "Remember that unit tests are meant to test one specific piece of functionality - don't try to group too much in your tests, as otherwise the error messages can become less meaningful, making harder to track down the actual problem.\n",
    "\n",
    "A recommended practice is either to set up unit tests **before** you write your code, or soon after. Here I left writing unit tests to the end, and this leads to badly written, or simply omitted unit tests because it becomes too tedious!"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "I'm only going to add a few more tests, and you can continue writing them on your own. We'll use unit tests again, and introduce additional functionality over time."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 21,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    \n",
    "    def test_create_timezone(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual('ABC', tz.name)\n",
    "        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)\n",
    "        \n",
    "    def test_timezones_equal(self):\n",
    "        tz1 = TimeZone('ABC', -1, -30)\n",
    "        tz2 = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual(tz1, tz2)\n",
    "        \n",
    "    def test_timezones_not_equal(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        \n",
    "        test_timezones = (\n",
    "            TimeZone('DEF', -1, -30),\n",
    "            TimeZone('ABC', -1, 0),\n",
    "            TimeZone('ABC', 1, -30)\n",
    "        )\n",
    "        for i, test_tz in enumerate(test_timezones):\n",
    "            with self.subTest(test_number=i):\n",
    "                self.assertNotEqual(tz, test_tz)\n",
    "                \n",
    "    def test_create_account(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, tz, balance)\n",
    "        self.assertEqual(account_number, a.account_number)\n",
    "        self.assertEqual(first_name, a.first_name)\n",
    "        self.assertEqual(last_name, a.last_name)\n",
    "        self.assertEqual(first_name + ' ' + last_name, a.full_name)\n",
    "        self.assertEqual(tz, a.timezone)\n",
    "        self.assertEqual(balance, a.balance)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 22,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_create_account (__main__.TestAccount) ... ok\n",
      "test_create_timezone (__main__.TestAccount) ... ok\n",
      "test_timezones_equal (__main__.TestAccount) ... ok\n",
      "test_timezones_not_equal (__main__.TestAccount) ... ok\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 4 tests in 0.003s\n",
      "\n",
      "OK\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "One last piece of unit testing functionality, is handling exceptions when they are **expected**, for example creating an account with an empty first name should result in a `ValueError` exception. We can write a unit test that will test this expected exception, and which will fail if the exception is not encountered (or is a different exception).\n",
    "\n",
    "To do this we need to indicate that an exception is expected, as well as the expected exception class."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 23,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    \n",
    "    def test_create_timezone(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual('ABC', tz.name)\n",
    "        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)\n",
    "        \n",
    "    def test_timezones_equal(self):\n",
    "        tz1 = TimeZone('ABC', -1, -30)\n",
    "        tz2 = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual(tz1, tz2)\n",
    "        \n",
    "    def test_timezones_not_equal(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        \n",
    "        test_timezones = (\n",
    "            TimeZone('DEF', -1, -30),\n",
    "            TimeZone('ABC', -1, 0),\n",
    "            TimeZone('ABC', 1, -30)\n",
    "        )\n",
    "        for i, test_tz in enumerate(test_timezones):\n",
    "            with self.subTest(test_number=i):\n",
    "                self.assertNotEqual(tz, test_tz)\n",
    "                \n",
    "    def test_create_account(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, tz, balance)\n",
    "        self.assertEqual(account_number, a.account_number)\n",
    "        self.assertEqual(first_name, a.first_name)\n",
    "        self.assertEqual(last_name, a.last_name)\n",
    "        self.assertEqual(first_name + ' ' + last_name, a.full_name)\n",
    "        self.assertEqual(tz, a.timezone)\n",
    "        self.assertEqual(balance, a.balance)\n",
    "        \n",
    "    def test_create_account_blank_first_name(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = ''\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = 100.00\n",
    "        \n",
    "        with self.assertRaises(ValueError):\n",
    "            a = Account(account_number, first_name, last_name, tz, balance)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 24,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_create_account (__main__.TestAccount) ... ok\n",
      "test_create_account_blank_first_name (__main__.TestAccount) ... ok\n",
      "test_create_timezone (__main__.TestAccount) ... ok\n",
      "test_timezones_equal (__main__.TestAccount) ... ok\n",
      "test_timezones_not_equal (__main__.TestAccount) ... ok\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 5 tests in 0.003s\n",
      "\n",
      "OK\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "But, if we were looking for a different exception:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 25,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    \n",
    "    def test_create_timezone(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual('ABC', tz.name)\n",
    "        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)\n",
    "        \n",
    "    def test_timezones_equal(self):\n",
    "        tz1 = TimeZone('ABC', -1, -30)\n",
    "        tz2 = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual(tz1, tz2)\n",
    "        \n",
    "    def test_timezones_not_equal(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        \n",
    "        test_timezones = (\n",
    "            TimeZone('DEF', -1, -30),\n",
    "            TimeZone('ABC', -1, 0),\n",
    "            TimeZone('ABC', 1, -30)\n",
    "        )\n",
    "        for i, test_tz in enumerate(test_timezones):\n",
    "            with self.subTest(test_number=i):\n",
    "                self.assertNotEqual(tz, test_tz)\n",
    "                \n",
    "    def test_create_account(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, tz, balance)\n",
    "        self.assertEqual(account_number, a.account_number)\n",
    "        self.assertEqual(first_name, a.first_name)\n",
    "        self.assertEqual(last_name, a.last_name)\n",
    "        self.assertEqual(first_name + ' ' + last_name, a.full_name)\n",
    "        self.assertEqual(tz, a.timezone)\n",
    "        self.assertEqual(balance, a.balance)\n",
    "        \n",
    "    def test_create_account_blank_first_name(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = ''\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = 100.00\n",
    "        \n",
    "        with self.assertRaises(TypeError):\n",
    "            a = Account(account_number, first_name, last_name, tz, balance)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 26,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_create_account (__main__.TestAccount) ... ok\n",
      "test_create_account_blank_first_name (__main__.TestAccount) ... ERROR\n",
      "test_create_timezone (__main__.TestAccount) ... ok\n",
      "test_timezones_equal (__main__.TestAccount) ... ok\n",
      "test_timezones_not_equal (__main__.TestAccount) ... ok\n",
      "\n",
      "======================================================================\n",
      "ERROR: test_create_account_blank_first_name (__main__.TestAccount)\n",
      "----------------------------------------------------------------------\n",
      "Traceback (most recent call last):\n",
      "  File \"<ipython-input-25-ff9e533b4ff8>\", line 48, in test_create_account_blank_first_name\n",
      "    a = Account(account_number, first_name, last_name, tz, balance)\n",
      "  File \"<ipython-input-2-aa0deed29ed0>\", line 15, in __init__\n",
      "    self.first_name = first_name\n",
      "  File \"<ipython-input-2-aa0deed29ed0>\", line 34, in first_name\n",
      "    self.validate_and_set_name('_first_name', value, 'First Name')\n",
      "  File \"<ipython-input-2-aa0deed29ed0>\", line 77, in validate_and_set_name\n",
      "    raise ValueError(f'{field_title} cannot be empty.')\n",
      "ValueError: First Name cannot be empty.\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 5 tests in 0.004s\n",
      "\n",
      "FAILED (errors=1)\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "As you can start to see, there are a lot of gaps in our `Account` implementation. For example, we allow empty account numbers, negative starting balances. As you start writing unit tests you will not only discover bugs in your code, but also gaps in your design and implementation!\n",
    "\n",
    "Let's fix our unit test back to expecting a `ValueError`, and write a few more."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 27,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    \n",
    "    def test_create_timezone(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual('ABC', tz.name)\n",
    "        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)\n",
    "        \n",
    "    def test_timezones_equal(self):\n",
    "        tz1 = TimeZone('ABC', -1, -30)\n",
    "        tz2 = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual(tz1, tz2)\n",
    "        \n",
    "    def test_timezones_not_equal(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        \n",
    "        test_timezones = (\n",
    "            TimeZone('DEF', -1, -30),\n",
    "            TimeZone('ABC', -1, 0),\n",
    "            TimeZone('ABC', 1, -30)\n",
    "        )\n",
    "        for i, test_tz in enumerate(test_timezones):\n",
    "            with self.subTest(test_number=i):\n",
    "                self.assertNotEqual(tz, test_tz)\n",
    "                \n",
    "    def test_create_account(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, tz, balance)\n",
    "        self.assertEqual(account_number, a.account_number)\n",
    "        self.assertEqual(first_name, a.first_name)\n",
    "        self.assertEqual(last_name, a.last_name)\n",
    "        self.assertEqual(first_name + ' ' + last_name, a.full_name)\n",
    "        self.assertEqual(tz, a.timezone)\n",
    "        self.assertEqual(balance, a.balance)\n",
    "        \n",
    "    def test_create_account_blank_first_name(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = ''\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = 100.00\n",
    "        \n",
    "        with self.assertRaises(ValueError):\n",
    "            a = Account(account_number, first_name, last_name, tz, balance)\n",
    "            \n",
    "    def test_account_deposit_ok(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, initial_balance=balance)\n",
    "        conf_code = a.deposit(100)\n",
    "        self.assertEqual(200, a.balance)\n",
    "        self.assertIn('D-', conf_code)\n",
    "    \n",
    "    def test_account_deposit_negative_amount(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, initial_balance=balance)\n",
    "        with self.assertRaises(ValueError):\n",
    "            conf_code = a.deposit(-100)\n",
    "        \n",
    "    def test_account_withdraw_ok(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, initial_balance=balance)\n",
    "        conf_code = a.withdraw(20)\n",
    "        self.assertEqual(80, a.balance)\n",
    "        self.assertIn('W-', conf_code)\n",
    "        \n",
    "    \n",
    "    def test_account_withdraw_overdraw(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, initial_balance=balance)\n",
    "        conf_code = a.withdraw(200)\n",
    "        self.assertIn('X-', conf_code)\n",
    "        self.assertEqual(balance, a.balance)\n",
    "        "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_account_deposit_negative_amount (__main__.TestAccount) ... ok\n",
      "test_account_deposit_ok (__main__.TestAccount) ... ok\n",
      "test_account_withdraw_ok (__main__.TestAccount) ... ok\n",
      "test_account_withdraw_overdraw (__main__.TestAccount) ... ok\n",
      "test_create_account (__main__.TestAccount) ... ok\n",
      "test_create_account_blank_first_name (__main__.TestAccount) ... ok\n",
      "test_create_timezone (__main__.TestAccount) ... ok\n",
      "test_timezones_equal (__main__.TestAccount) ... ok\n",
      "test_timezones_not_equal (__main__.TestAccount) ... ok\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 9 tests in 0.005s\n",
      "\n",
      "OK\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's add one more unit test, that checkes to make sure we cannot create accounts with negative balances:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 29,
   "metadata": {},
   "outputs": [],
   "source": [
    "class TestAccount(unittest.TestCase):\n",
    "    \n",
    "    def test_create_timezone(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual('ABC', tz.name)\n",
    "        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)\n",
    "        \n",
    "    def test_timezones_equal(self):\n",
    "        tz1 = TimeZone('ABC', -1, -30)\n",
    "        tz2 = TimeZone('ABC', -1, -30)\n",
    "        self.assertEqual(tz1, tz2)\n",
    "        \n",
    "    def test_timezones_not_equal(self):\n",
    "        tz = TimeZone('ABC', -1, -30)\n",
    "        \n",
    "        test_timezones = (\n",
    "            TimeZone('DEF', -1, -30),\n",
    "            TimeZone('ABC', -1, 0),\n",
    "            TimeZone('ABC', 1, -30)\n",
    "        )\n",
    "        for i, test_tz in enumerate(test_timezones):\n",
    "            with self.subTest(test_number=i):\n",
    "                self.assertNotEqual(tz, test_tz)\n",
    "                \n",
    "    def test_create_account(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, tz, balance)\n",
    "        self.assertEqual(account_number, a.account_number)\n",
    "        self.assertEqual(first_name, a.first_name)\n",
    "        self.assertEqual(last_name, a.last_name)\n",
    "        self.assertEqual(first_name + ' ' + last_name, a.full_name)\n",
    "        self.assertEqual(tz, a.timezone)\n",
    "        self.assertEqual(balance, a.balance)\n",
    "        \n",
    "    def test_create_account_blank_first_name(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = ''\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = 100.00\n",
    "        \n",
    "        with self.assertRaises(ValueError):\n",
    "            a = Account(account_number, first_name, last_name, tz, balance)\n",
    "            \n",
    "    def test_create_account_negative_balance(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        tz = TimeZone('TZ', 1, 30)\n",
    "        balance = -100.00\n",
    "        \n",
    "        with self.assertRaises(ValueError):\n",
    "            a = Account(account_number, first_name, last_name, tz, balance)\n",
    "            \n",
    "    def test_account_deposit_ok(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, initial_balance=balance)\n",
    "        conf_code = a.deposit(100)\n",
    "        self.assertEqual(200, a.balance)\n",
    "        self.assertIn('D-', conf_code)\n",
    "    \n",
    "    def test_account_deposit_negative_amount(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, initial_balance=balance)\n",
    "        with self.assertRaises(ValueError):\n",
    "            conf_code = a.deposit(-100)\n",
    "        \n",
    "    def test_account_withdraw_ok(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, initial_balance=balance)\n",
    "        conf_code = a.withdraw(20)\n",
    "        self.assertEqual(80, a.balance)\n",
    "        self.assertIn('W-', conf_code)\n",
    "        \n",
    "    \n",
    "    def test_account_withdraw_overdraw(self):\n",
    "        account_number = 'A100'\n",
    "        first_name = 'FIRST'\n",
    "        last_name = 'LAST'\n",
    "        balance = 100.00\n",
    "        \n",
    "        a = Account(account_number, first_name, last_name, initial_balance=balance)\n",
    "        conf_code = a.withdraw(200)\n",
    "        self.assertIn('X-', conf_code)\n",
    "        self.assertEqual(balance, a.balance)\n",
    "        "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 30,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_account_deposit_negative_amount (__main__.TestAccount) ... ok\n",
      "test_account_deposit_ok (__main__.TestAccount) ... ok\n",
      "test_account_withdraw_ok (__main__.TestAccount) ... ok\n",
      "test_account_withdraw_overdraw (__main__.TestAccount) ... ok\n",
      "test_create_account (__main__.TestAccount) ... ok\n",
      "test_create_account_blank_first_name (__main__.TestAccount) ... ok\n",
      "test_create_account_negative_balance (__main__.TestAccount) ... FAIL\n",
      "test_create_timezone (__main__.TestAccount) ... ok\n",
      "test_timezones_equal (__main__.TestAccount) ... ok\n",
      "test_timezones_not_equal (__main__.TestAccount) ... ok\n",
      "\n",
      "======================================================================\n",
      "FAIL: test_create_account_negative_balance (__main__.TestAccount)\n",
      "----------------------------------------------------------------------\n",
      "Traceback (most recent call last):\n",
      "  File \"<ipython-input-29-94814e310335>\", line 58, in test_create_account_negative_balance\n",
      "    a = Account(account_number, first_name, last_name, tz, balance)\n",
      "AssertionError: ValueError not raised\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 10 tests in 0.006s\n",
      "\n",
      "FAILED (failures=1)\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Oh-oh, we are not raising an exception! That's a bug in our code.\n",
    "\n",
    "So, let's fix it, and re-run the tests:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 31,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Account:\n",
    "    transaction_counter = itertools.count(100)\n",
    "    _interest_rate = 0.5  # percentage\n",
    "    \n",
    "    _transaction_codes = {\n",
    "        'deposit': 'D',\n",
    "        'withdraw': 'W',\n",
    "        'interest': 'I',\n",
    "        'rejected': 'X'\n",
    "    }\n",
    "    \n",
    "    def __init__(self, account_number, first_name, last_name, timezone=None, initial_balance=0):\n",
    "        # in practice we probably would want to add checks to make sure these values are valid / non-empty\n",
    "        self._account_number = account_number\n",
    "        self.first_name = first_name\n",
    "        self.last_name = last_name\n",
    "        \n",
    "        if timezone is None:\n",
    "            timezone = TimeZone('UTC', 0, 0)\n",
    "        self.timezone = timezone\n",
    "        \n",
    "        self._balance = Account.validate_real_number(initial_balance, min_value=0)\n",
    "        \n",
    "    @property\n",
    "    def account_number(self):\n",
    "        return self._account_number\n",
    "    \n",
    "    @property \n",
    "    def first_name(self):\n",
    "        return self._first_name\n",
    "    \n",
    "    @first_name.setter\n",
    "    def first_name(self, value):\n",
    "        self.validate_and_set_name('_first_name', value, 'First Name')\n",
    "        \n",
    "    @property\n",
    "    def last_name(self):\n",
    "        return self._last_name\n",
    "    \n",
    "    @last_name.setter\n",
    "    def last_name(self, value):\n",
    "        self.validate_and_set_name('_last_name', value, 'Last Name')\n",
    "        \n",
    "    # also going to create a full_name computed property, for ease of use\n",
    "    @property\n",
    "    def full_name(self):\n",
    "        return f'{self.first_name} {self.last_name}'\n",
    "        \n",
    "    @property\n",
    "    def timezone(self):\n",
    "        return self._timezone\n",
    "    \n",
    "    @property\n",
    "    def balance(self):\n",
    "        return self._balance\n",
    "    \n",
    "    @timezone.setter\n",
    "    def timezone(self, value):\n",
    "        if not isinstance(value, TimeZone):\n",
    "            raise ValueError('Time zone must be a valid TimeZone object.')\n",
    "        self._timezone = value\n",
    "          \n",
    "    @classmethod\n",
    "    def get_interest_rate(cls):\n",
    "        return cls._interest_rate\n",
    "    \n",
    "    @classmethod\n",
    "    def set_interest_rate(cls, value):\n",
    "        if not isinstance(value, numbers.Real):\n",
    "            raise ValueError('Interest rate must be a real number')\n",
    "        if value < 0:\n",
    "            raise ValueError('Interest rate cannot be negative.')\n",
    "        cls._interest_rate = value\n",
    "        \n",
    "    def validate_and_set_name(self, property_name, value, field_title):\n",
    "        if len(str(value).strip()) == 0:\n",
    "            raise ValueError(f'{field_title} cannot be empty.')\n",
    "        setattr(self, property_name, value)\n",
    "        \n",
    "    @staticmethod\n",
    "    def validate_real_number(value, min_value=None):\n",
    "        if not isinstance(value, numbers.Real):\n",
    "            raise ValueError('Value must be a real number.')\n",
    "            \n",
    "        if min_value is not None and value < min_value:\n",
    "            raise ValueError(f'Value must be at least {min_value}')\n",
    "            \n",
    "        # validation passed, return valid value\n",
    "        return value\n",
    "    \n",
    "    def generate_confirmation_code(self, transaction_code):\n",
    "        # main difficulty here is to generate the current time in UTC using this formatting:\n",
    "        # YYYYMMDDHHMMSS\n",
    "        dt_str = datetime.utcnow().strftime('%Y%m%d%H%M%S')\n",
    "        return f'{transaction_code}-{self.account_number}-{dt_str}-{next(Account.transaction_counter)}'\n",
    "    \n",
    "    @staticmethod\n",
    "    def parse_confirmation_code(confirmation_code, preferred_time_zone=None):\n",
    "        # dummy-A100-20190325224918-101\n",
    "        parts = confirmation_code.split('-')\n",
    "        if len(parts) != 4:\n",
    "            # really simplistic validation here - would need something better\n",
    "            raise ValueError('Invalid confirmation code')\n",
    "        \n",
    "        # unpack into separate variables\n",
    "        transaction_code, account_number, raw_dt_utc, transaction_id = parts\n",
    "        \n",
    "        # need to convert raw_dt_utc into a proper datetime object\n",
    "        try:\n",
    "            dt_utc = datetime.strptime(raw_dt_utc, '%Y%m%d%H%M%S')\n",
    "        except ValueError as ex:\n",
    "            # again, probably need better error handling here\n",
    "            raise ValueError('Invalid transaction datetime') from ex\n",
    "          \n",
    "        if preferred_time_zone is None:\n",
    "            preferred_time_zone = TimeZone('UTC', 0, 0)\n",
    "            \n",
    "        if not isinstance(preferred_time_zone, TimeZone):\n",
    "            raise ValueError('Invalid TimeZone specified.')\n",
    "            \n",
    "        dt_preferred = dt_utc + preferred_time_zone.offset\n",
    "        dt_preferred_str = f\"{dt_preferred.strftime('%Y-%m-%d %H:%M:%S')} ({preferred_time_zone.name})\"\n",
    "        \n",
    "        return Confirmation(account_number, transaction_code, transaction_id, dt_utc.isoformat(), dt_preferred_str)\n",
    "    \n",
    "    def deposit(self, value):\n",
    "        value = Account.validate_real_number(value, min_value=0.01)\n",
    "       \n",
    "        # get transaction code\n",
    "        transaction_code = Account._transaction_codes['deposit']\n",
    "        \n",
    "        # generate a confirmation code\n",
    "        conf_code = self.generate_confirmation_code(transaction_code)\n",
    "        \n",
    "        # make deposit and return conf code\n",
    "        self._balance += value\n",
    "        return conf_code\n",
    "    \n",
    "    def withdraw(self, value):\n",
    "        value = Account.validate_real_number(value, min_value=0.01)\n",
    "        accepted = False\n",
    "        if self.balance - value < 0:\n",
    "            # insufficient funds - we'll reject this transaction\n",
    "            transaction_code = Account._transaction_codes['rejected']\n",
    "        else:\n",
    "            transaction_code = Account._transaction_codes['withdraw']\n",
    "            accepted = True\n",
    "            \n",
    "        conf_code = self.generate_confirmation_code(transaction_code)\n",
    "        \n",
    "        # Doing this here in case there's a problem generating a confirmation code\n",
    "        # - do not want to modify the balance if we cannot generate a transaction code successfully\n",
    "        if accepted:\n",
    "            self._balance -= value\n",
    "            \n",
    "        return conf_code\n",
    "    \n",
    "    def pay_interest(self):\n",
    "        interest = self.balance * Account.get_interest_rate() / 100\n",
    "        conf_code = self.generate_confirmation_code(Account._transaction_codes['interest'])\n",
    "        self._balance += interest\n",
    "        return conf_code"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 32,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "test_account_deposit_negative_amount (__main__.TestAccount) ... ok\n",
      "test_account_deposit_ok (__main__.TestAccount) ... ok\n",
      "test_account_withdraw_ok (__main__.TestAccount) ... ok\n",
      "test_account_withdraw_overdraw (__main__.TestAccount) ... ok\n",
      "test_create_account (__main__.TestAccount) ... ok\n",
      "test_create_account_blank_first_name (__main__.TestAccount) ... ok\n",
      "test_create_account_negative_balance (__main__.TestAccount) ... ok\n",
      "test_create_timezone (__main__.TestAccount) ... ok\n",
      "test_timezones_equal (__main__.TestAccount) ... ok\n",
      "test_timezones_not_equal (__main__.TestAccount) ... ok\n",
      "\n",
      "----------------------------------------------------------------------\n",
      "Ran 10 tests in 0.006s\n",
      "\n",
      "OK\n"
     ]
    }
   ],
   "source": [
    "run_tests(TestAccount)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now our tests pass!"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "And so on... !\n",
    "\n",
    "You should add at least a few more unit tests to this to get some extra practice."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.6.7"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
